/*
 * pixiv_down - CLI-based downloading tool for https://www.pixiv.net.
 * Copyright (C) 2024  Mio
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, version 3 of the License.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */
module pd.pixiv_downloader;

import pd.configuration;
import std.experimental.logger;
import mlib.term;

import pd.converter;
import pd.pixiv;
import pd.utils;

import std.datetime.systime : SysTime;

void downloadArtwork(ArtworkInfo info, const ref Config config, bool force = false)
{
   import std.stdio : stdout;

   stdout.writefln("Downloading %s by %s...", info.title, info.userName);

   switch(info.type) {
   case "illustration":
   case "manga":
      downloadPagedWork(info, config, force);
      break;
   case "ugoira":
      if (!config.manager)
      {
         stdout.writefln("Skipped %s by %s (Ugoira, no plugin loaded.)", info.title, info.userName);
         return;
      }

      downloadUgoira(info, force, config);
      break;
   default:
      // Shouldn't happen, since fetchArtworkInfo will fail.
      assert(0, info.type);
   }

   Term.goUpAndClearLine(1);
   stdout.writefln("Downloaded %s by %s.", info.title, info.userName);
}

void downloadNovel(NovelInfo novel, const ref Config config, bool force = false)
{
   import std.array : replace;
   import std.file : exists, mkdirRecurse;
   import std.path : buildPath;
   import std.stdio : File, stdout;

   stdout.writefln("Downloading %s by %s...", novel.title, novel.userName);
   const dirname = makeSafe(novel.userId ~ "_" ~ novel.userName);
   const outputDirectory = buildPath(config.outputDirectory, dirname, novel.id);
   const filename = buildPath(outputDirectory, novel.id ~ ".txt");

   if (exists(filename) && !force) {
      Term.goUpAndClearLine(1);
      stdout.writefln("Downloaded %s by %s.", novel.title, novel.userName);
      return;
   }

   mkdirRecurse(outputDirectory);
   with (File(filename, "w+")) {
      writeln(novel.description.replace("<br />", "\n"));
      writeln("\n===========================================\n");
      writeln(novel.content);
   }

   const createDate = SysTime.fromISOExtString(novel.createDate);
   setTimes(filename, createDate, createDate);
   setTimes(outputDirectory, createDate, createDate);
   Term.goUpAndClearLine(1);
   stdout.writefln("Downloaded %s by %s.", novel.title, novel.userName);
}

private:

immutable struct Artwork
{
   ArtworkPage page;
   string id;
   string createDate;
   long numberOfPages;
   bool manga;

   alias page this;
}

// Illustrations and Manga.
void downloadPagedWork(ArtworkInfo info, const ref Config config, bool force = false)
{
   import std.file : mkdirRecurse;
   import std.path : buildPath;
   import std.stdio : stdout, stderr;

   ArtworkPage[] pages = fetchArtworkPages(info.id, config);
   tracef("fetched artwork %s (%d pages)", info.id, pages.length);

   const needsSubdir = info.numberOfPages > 1;
   const userDirectory = makeSafe(info.userId ~ "_" ~ info.userName);
   const outputDirectory = buildPath(
      config.outputDirectory,
      userDirectory,
      needsSubdir ? info.id : ""
   );
   infof("outputDirectory = %s", outputDirectory);
   mkdirRecurse(outputDirectory);

   const totalPages = pages.length;

   foreach(index, page; pages) {
      stderr.writef("    Downloading page %d of %d...", index + 1, totalPages);
      stderr.flush();

      const artwork = Artwork(page, info.id, info.createDate, totalPages,
            info.type == "manga");
      downloadPage(artwork, outputDirectory, force, config);

      Term.clearCurrentLine(Yes.useStderr);
   }

   if (totalPages > 1) {
      auto createDate = SysTime.fromISOExtString(info.createDate);
      setTimes(outputDirectory, createDate, createDate);
   }
}

void downloadPage(Artwork artwork, string outputDirectory, bool force, const ref Config config)
{
   import std.file : exists, rename;
   import std.net.curl : HTTP;
   import std.path : baseName, buildPath, extension;
   import std.stdio : File, stderr;

   auto client = HTTP(artwork.originalURL);
   client.addRequestHeader("accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8");
   client.addRequestHeader("referer", "https://www.pixiv.net/");
   client.setCookie("PHPSESSID=" ~ config.sessionid);
   client.setUserAgent(UserAgent);

   const filename = (artwork.manga || artwork.numberOfPages > 1) ?
      buildPath(outputDirectory, baseName(artwork.originalURL)) :
      buildPath(outputDirectory, artwork.id ~ artwork.originalURL.extension);
   const partFilename = filename ~ ".part";
   bool resumingDownload = false;

   if (exists(filename) && !force) {
      info("Already downloaded page.");
      return;
   }

   if (exists(partFilename) && !force) {
      // Need to check we can resume downloading.
      trace("Checking if we can resume download");
      client.method = HTTP.Method.head;
      client.onReceiveHeader = (in header, in value) {
         if (header == "accept-ranges" && value != "none") {
            resumingDownload = true;
         }
      };
      client.perform();
      client.method = HTTP.Method.get;

      info("Can resume HTTP download? %s", resumingDownload);
   }

   if (resumingDownload) {
      import std.file : getSize;
      import std.format : format;

      client.addRequestHeader("range",
         format("bytes=%d-", getSize(partFilename)));
   }

   auto file = File(partFilename, resumingDownload ? "ab" : "wb");
   client.onReceive = (ubyte[] data) {
      file.rawWrite(data);
      return data.length;
   };
   client.perform();
   file.flush();
   rename(partFilename, filename);

   auto createDate = SysTime.fromISOExtString(artwork.createDate);
   setTimes(filename, createDate, createDate);
   sleep(1, 2, false);
}


void downloadUgoira(ArtworkInfo artworkInfo, bool force, const ref Config config)
{
   import std.concurrency : send, spawn;
   import std.file : exists, remove, rmdirRecurse;
   import std.path : buildPath;
   import std.stdio : stderr;
   import pd.utils;

   const outputDirectory = buildPath(
      config.outputDirectory,
      artworkInfo.userId ~ "_" ~ makeSafe(artworkInfo.userName)
   );

   if (exists(buildPath(outputDirectory, artworkInfo.id ~ ".gif")) && !force) {
      infof("already downloaded %s.gif", buildPath(outputDirectory, artworkInfo.id));
      return;
   }

   assert(config.manager !is null, "invalid ugoira plugin manager");

   UgoiraInfo info = fetchUgoiraInfo(artworkInfo, config);
   trace("fetched Ugoira info");
   infof("ugoira mime type: %s", info.mimeType);

   Term.disableCursor();
   scope(failure) Term.enableCursor();
   auto tid = spawn(&runSpinner, "    Downloading Ugoira ZIP");
   const zipPath = downloadUgoiraZip(info.originalSource, config);
   scope(exit) remove(zipPath);
   send(tid, StopSpinnerMessage());
   Term.enableCursor();

   const extZipPath = extractZip(zipPath, artworkInfo.id);
   scope(exit) rmdirRecurse(extZipPath);
   tracef("extracted ZIP to %s", extZipPath);

   Term.clearCurrentLine(Yes.useStderr);

   Term.disableCursor();
   tid = spawn(&runSpinner, "    Creating Ugoira GIF");

   createGIF(extZipPath, artworkInfo, info, outputDirectory, config);
   trace("finished creating GIF");
   send(tid, StopSpinnerMessage());
   Term.clearCurrentLine(Yes.useStderr);
   Term.enableCursor();
   Term.clearCurrentLine(Yes.useStderr);
}

void createGIF(string frameDirs, ArtworkInfo artworkInfo, UgoiraInfo ugoiraInfo, string outputDirectory, const ref Config config)
{
   import std.file : mkdirRecurse;
   import std.path : buildPath;

   import pd.converter;

   const outputFilename = buildPath(outputDirectory, artworkInfo.id ~ ".gif");
   tracef("GIF output filename = %s", outputFilename);
   mkdirRecurse(outputDirectory);

   if (config.manager) {
      scope converter = config.manager.createConverter("GIF");
      foreach (frame; ugoiraInfo.frames) {
         const inputPath = buildPath(frameDirs, frame.filename);
         converter.appendFrame(inputPath);
         converter.setFrameDelay(frame.delay);
      }
      converter.write(outputFilename);
   }

   trace("setting GIF access and modification dates");
   const createDate = SysTime.fromISOExtString(artworkInfo.createDate);
   setTimes(outputFilename, createDate, createDate);
}

string downloadUgoiraZip(string url, const ref Config config)
{
   import std.file : exists, rename, tempDir;
   import std.net.curl : HTTP;
   import std.path : baseName, buildPath;
   import std.stdio : File;

   auto client = makeHTTPClient(config.sessionid, [
      "host": "i.pximg.net",
   ]);
   client.url = url;

   const filename = buildPath(tempDir(), baseName(url));
   const partFilename = filename ~ ".part";
   bool resumingDownload = false;

   if (exists(filename)) {
      info("already downloaded Ugoira ZIP");
      return filename;
   }

   if (exists(partFilename)) {
      trace("checking if we can resume downloading ZIP");
      client.method = HTTP.Method.head;
      client.onReceiveHeader = (in header, in value) {
         if (header == "accept-ranges" && "value" != "none") {
            resumingDownload = true;
         }
      };
      client.perform();
      client.method = HTTP.Method.get;
      infof("resuming downloading ZIP? %s", resumingDownload);
   }

   if (resumingDownload) {
      import std.file : getSize;
      import std.format : format;

      client.addRequestHeader("range",
         format("bytes=%d-", getSize(partFilename)));
   }

   auto file = File(partFilename, resumingDownload ? "ab" : "wb");
   client.onReceive = (ubyte[] data) {
      file.rawWrite(data);
      return data.length;
   };
   client.perform();
   file.flush();
   rename(partFilename, filename);

   return filename;
}

string extractZip(string zipPath, string dirName)
{
   import std.file;
   import std.path : buildPath;
   import std.typecons : scoped;
   import std.zip;

   const dir = buildPath(tempDir(), dirName);
   mkdirRecurse(dir);

   const originalDirectory = getcwd();
   chdir(dir);
   scope(exit) chdir(originalDirectory);

   auto archive = scoped!ZipArchive(read(zipPath));
   foreach(name, am; archive.directory) {
      archive.expand(am);
      write(name, am.expandedData);
   }

   return dir;
}

void setTimes(in string filename, in SysTime access, in SysTime modified)
{
   import std.file : stdSetTimes = setTimes;
   try {
      stdSetTimes(filename, access, modified);
   } catch (Exception e) {
      errorf("Failed to call setTimes(%s, %s, %s): %s", filename, access, modified, e.msg);
   }
}
